Skip to content

fix(auth): keep the OAuth listTools request alive across every phase - #241

Open
umutkeltek wants to merge 6 commits into
openclaw:mainfrom
umutkeltek:fix/daemon-listtools-progress-heartbeat
Open

fix(auth): keep the OAuth listTools request alive across every phase#241
umutkeltek wants to merge 6 commits into
openclaw:mainfrom
umutkeltek:fix/daemon-listtools-progress-heartbeat

Conversation

@umutkeltek

@umutkeltek umutkeltek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Resubmission of #237 with the correctness gap fixed and a clean history, now updated for the Codex and ClawSweeper review round.

The gap in #237

resolveOperationSocketBudget sized the listTools socket at 2 * timeoutMs + 5s: one phase for OAuth, one for a single tools/list. But McpRuntime.listTools() walks nextCursor and gives every page its own timeoutMs, so an OAuth wait plus two or more slow pages outlives the socket. The client sees a transport timeout, restarts the daemon, resends the listing, and the user gets the second OAuth flow this PR exists to prevent.

Any constant multiple has the same defect — it encodes a phase count the runtime does not promise.

The fix: observe the daemon instead of predicting it

While a request is in flight the daemon host emits newline-delimited progress frames. The client treats each frame as proof of life and restarts its socket deadline. That turns the socket deadline from an operation budget into a liveness budget:

  • a request survives however many sequential phases it needs — OAuth wait plus N pages — with no arithmetic on either side;
  • a daemon that actually goes silent is still torn down and restarted, exactly as before;
  • the operation deadline stays where it belongs: the daemon enforces it and answers operation_timeout, which the client does not retry.

resolveOperationSocketBudget and its 5s grace are gone. The listTools socket uses the caller deadline verbatim. Both peers decode frames incrementally (DaemonFrameDecoder), so the daemon's own status probe and stop handshake stay readable and a response with no trailing newline still parses. The daemon protocol is versioned, so a daemon started before this change is replaced rather than left speaking the old wire format.

Real behavior proof

A local OAuth-protected MCP server (dynamic client registration, PKCE S256, authorization code, bearer-gated /mcp) that paginates tools/list across 4 pages at 4s each. mcporter auth runs the real interactive browser flow — the browser launch is shimmed so approval lands 4s after the URL opens — routed through the real keep-alive daemon (lifecycle.mode = keep-alive, confirmed daemon pid). MCPORTER_OAUTH_TIMEOUT_MS=6000, so every individual phase is comfortably inside its deadline and only the total exceeds a fixed budget.

Before — 462865b, the head that was closed (2 * 6000 + 5000 = 17s socket budget):

[14:45:41.300] mcp -> 401 (no valid bearer token), advertising protected resource metadata
[14:45:45.352] authorize -> issuing code, redirecting to loopback callback
[14:45:45.362] token -> PKCE verified, access token issued
[14:45:45.388] tools/list page 1/4
[14:45:49.394] tools/list page 2/4
[14:45:53.398] tools/list page 3/4
[14:45:57.402] tools/list page 4/4
[14:45:58.729] tools/list page 1/4      <-- socket expired at ~17s; daemon restarted, listing replayed
[14:46:02.732] tools/list page 2/4
[14:46:06.735] tools/list page 3/4
[14:46:10.737] tools/list page 4/4

CLI exit 0, elapsed 34s
tools/list page 1 requests : 2
tools/list requests total  : 8

After — this branch:

[14:44:51.258] mcp -> 401 (no valid bearer token), advertising protected resource metadata
[14:44:55.771] authorize -> issuing code, redirecting to loopback callback
[14:44:55.777] token -> PKCE verified, access token issued
[14:44:55.795] tools/list page 1/4
[14:44:59.800] tools/list page 2/4
[14:45:03.805] tools/list page 3/4
[14:45:07.810] tools/list page 4/4

CLI exit 0, elapsed 21s
tools/list page 1 requests : 1
tools/list requests total  : 4

One authorization, one completed listing, no replay, no daemon restart. The replay in the "before" run begins 17.3s after the listing started, which is exactly the 2 * timeoutMs + 5s budget — the failure mode described in the #237 review, reproduced and then removed.

(The provider is a local fixture rather than a third-party service so the run is inspectable and repeatable; it exercises the real SDK OAuth client path — discovery, registration, PKCE, code exchange, bearer-gated MCP — not a stub.)

Review round: what changed

[P1] Liveness frames could outlive a wedged request. The premise as stated — that listResources/readResource have no operation timeout — does not hold: every SDK request carries DEFAULT_REQUEST_TIMEOUT_MSEC (60s) and every OAuth wait carries DEFAULT_OAUTH_CODE_TIMEOUT_MS (300s). But the underlying worry was real and I found a stronger case than the one reported: McpRuntime.listTools() walks nextCursor with no guard, so a server that repeats a cursor pages forever — and heartbeats would then keep the client waiting forever. Both are now closed:

  • operations with a fixed phase count (at most one connect plus one MCP request) carry an absolute ceiling equal to the sum of the deadlines the daemon already applies to those phases. It is derived, not guessed, and can only fire once every real deadline has been blown; a server that accepts and never answers now yields a non-retryable operation_timeout instead of an indefinite wait.
  • listTools, whose page count is data-dependent, keeps its per-page deadline and gains a repeated-cursor guard in the runtime.

[P2] Short deadlines could expire before the first frame. Fixed, and slightly further than suggested: the first frame is written immediately when the request is dispatched, and the cadence is derived from the caller's own deadline (resolveProgressInterval, sent on the request envelope) rather than fixed at 250ms. An immediate frame alone would still lose a sub-250ms deadline on the second gap.

[P3] Release-owned changelog. Correct — checked against this repo's history: my own merged #234 did not touch CHANGELOG.md; the maintainer added the entry at release with the thanks @… credit line. The entry has been removed from this branch.

[P1, ClawSweeper] An upgraded client could stop a live shared daemon mid-request. The protocol-version check classified any pre-v2 daemon as stale and called stop() on it from ensureDaemon(). A second client upgrading mid-request — OAuth code wait or a delayed cursor page — could race ahead of the first client's in-flight call, kill the daemon, and force the very replay this PR is meant to remove. The replacement is now an idle-drain transition, not a preempt:

  • the host's status response reports the live in-flight count (StatusResult.activeRequests, sampled at response time so a long-running request still shows as in flight). The field is optional, so v1 daemons that cannot advertise it still answer old clients.
  • restartDaemon() polls the live daemon up to MCPORTER_DAEMON_DRAIN_TIMEOUT_MS (60s default, env-var overridable) for the counter to read zero before issuing stop. Pre-v2 daemons are treated as permanently busy, so the drain timeout is also their safety bound.
  • a busy daemon that another client replaces mid-drain is left alone — waitForDaemonIdle returns both drained and replacedByPeer, and the upgrading client bails out without stop.

If the daemon is still busy past the timeout the replacement is abandoned and the caller fails loud rather than killing the peer.

Regression coverage

tests/daemon-listtools-progress.test.ts drives the real client transport against the real host framing over a socket:

  • survives an OAuth wait followed by several paginated tools/list pages — an OAuth wait plus three delayed pages, every phase longer than the socket deadline. Asserts the tools resolve, listTools ran exactly once (no replay), one listTools request reached the daemon, and launchDaemonDetached was never called (no restart). Verified it fails without the mechanism: stubbing out the progress emitter makes it die with Daemon did not stop before restart could begin.
  • reaches a deadline shorter than the default progress interval — the P2 case.
  • emits the first progress frame before any cadence interval can elapse — pins the P2 fix at a 30ms deadline, well under the 250ms default cadence.
  • still trips the socket deadline when the daemon stops sending progress frames — silence is still fatal.
  • walks every cursor page but refuses to page forever on a repeated cursor — the P1 case.

tests/daemon-client-timeout.test.ts pins the budget itself: the listTools socket deadline equals the caller deadline, so a phase-count multiplier cannot come back unnoticed.

tests/daemon-host.test.ts pins the operation ceiling for the fixed-phase methods against a wedged runtime: callTool, listResources, and readResource all return operation_timeout after the sum of MCPORTER_OAUTH_TIMEOUT_MS and DEFAULT_REQUEST_TIMEOUT_MSEC, using fake timers so the test does not wait the production minute-plus.

tests/daemon-client-config-stale.test.ts pins the ClawSweeper P1 fix:

  • waits for a busy shared daemon to drain before sending stop — the upgrading client observes activeRequests flip to zero before issuing stop, proving no in-flight OAuth or cursor page was killed.
  • does not stop a peer fresh daemon if it replaces the busy one mid-drain — a second client that wins the race to replace the busy daemon during the drain window has its fresh daemon left alone; the upgrading client does not call stop and does not relaunch.
  • refuses to replace a daemon that never drains before the timeoutMCPORTER_DAEMON_DRAIN_TIMEOUT_MS=500 exercises the refusal branch without waiting the production minute, asserting the busy peer's daemon is left running.

Housekeeping

  • Clean commit history, no AI co-author trailers, single author.
  • Rebased onto current main.
  • pnpm check and pnpm test green locally (860 passed, 3 skipped); CI green on Ubuntu, macOS and Windows.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab62f071c3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/daemon/host.ts Outdated
preParsedRequest
);
socket.write(JSON.stringify(response), () => {
const stopProgress = startProgressFrames(socket, preParsedRequest?.id ?? 'unknown');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Limit heartbeat frames to operations with their own deadline

When a keep-alive server accepts resources/list or resources/read but never responds, this unconditional heartbeat continues while the daemon event loop remains healthy, and the client resets its 30-second socket deadline on every frame. Those runtime methods have no operation timeout, so the CLI now waits forever instead of timing out, restarting the daemon, and retrying as it did before this commit. Restrict these heartbeats to the bounded listTools path or retain an absolute recovery deadline for the other methods.

Useful? React with 👍 / 👎.

Comment thread src/daemon/protocol.ts
// deadline, so a request stays alive for as many phases as it needs -- an OAuth
// code wait plus any number of paginated `tools/list` pages -- without the
// client having to predict how many phases there will be.
export const DAEMON_PROGRESS_INTERVAL_MS = 250;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit a heartbeat before short idle deadlines

When an API caller supplies ListToolsOptions.timeoutMs <= 250 (also accepted by --oauth-timeout), the client arms that same socket timeout immediately, but the host waits 250 ms before its first progress frame. Because the daemon starts the operation timeout only after receiving and dispatching the request, the socket deadline can fire first, causing invoke() to classify this as a transport failure and restart/replay the request instead of returning the non-retryable operation_timeout. Send an initial frame immediately or coordinate the interval with the requested timeout.

Useful? React with 👍 / 👎.

@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from ab62f07 to 9a296ab Compare July 27, 2026 13:57
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P1 Urgent regression or broken agent/channel workflow affecting real users now. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. labels Jul 27, 2026
@clawsweeper

clawsweeper Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed July 31, 2026, 12:07 PM ET / 16:07 UTC.

ClawSweeper review

What this changes

This PR forwards the OAuth timeout through tool discovery, keeps long OAuth-plus-pagination daemon requests alive with progress frames, and safely drains shared daemons during protocol replacement.

Merge readiness

⚠️ Needs maintainer review before merge - 4 items remain

Keep this PR open for maintainer review. The supplied PR evidence is strong and the prior concrete review findings appear addressed, but repository inspection could not run: every read-only command failed before execution with bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted, so current-main code, the final PR head, local history, and matching maintainer notes could not be independently verified.

Priority: P1
Reviewed head: fb1901d325fa7ae4c84438b1b791e2b8d49b3846
Owner decision: Required. See Decision needed.

Review scores

Measure Result What it means
Overall readiness 🦐 gold shrimp (3/6) The PR has unusually strong real-behavior evidence, but merge readiness remains limited by an intentional upgrade-policy decision and unavailable local final-head verification.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (live_output): The PR body supplies a before/after real local OAuth MCP run through the keep-alive daemon, showing one authorization and four list pages without replay after the change; no secrets are exposed in the shown output.
Patch quality 🦐 gold shrimp (3/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): The PR body supplies a before/after real local OAuth MCP run through the keep-alive daemon, showing one authorization and four list pages without replay after the change; no secrets are exposed in the shown output.
Evidence reviewed 4 items Repository-inspection failure: The required read-only inspection could not start: both the repository wrapper and direct shell command attempts terminated with bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted. No current-tree, git-history, or maintainer-note claim in this review relies on unperformed local inspection.
Real behavior proof supplied: The PR body documents an OAuth-protected local MCP server with dynamic registration, PKCE, bearer-gated access, four delayed tools/list pages, and before/after request counts showing one completed listing with no replay on the proposed branch.
Prior review continuity: The provided review context records the earlier busy-daemon replacement finding and later commits that add idle drain, peer-replacement detection, and an unconditional live-PID mismatch short-circuit. The latest supplied head is fb1901d325fa7ae4c84438b1b791e2b8d49b3846, but it could not be inspected locally in this environment.
Findings None None.
Security None None.

How this fits together

mcporter auth drives OAuth through tools/list, sometimes through a shared keep-alive daemon. The CLI sends a request to that daemon, which invokes the MCP runtime and must distinguish genuine operation timeouts from a silent or replaced daemon.

flowchart LR
  CLI[CLI auth command] --> Client[Keep-alive daemon client]
  Client --> Socket[Daemon socket protocol]
  Socket --> Host[Daemon host]
  Host --> Runtime[MCP runtime and OAuth]
  Runtime --> Server[OAuth MCP server]
  Host --> Result[Tool list or timeout]
  Result --> CLI
Loading

Decision needed

Question Recommendation
During a client/daemon protocol upgrade, should mcporter fail the new command after the bounded drain timeout when it encounters a busy pre-v2 shared daemon, rather than stop that daemon and interrupt the peer request? Keep the bounded non-preemptive transition: Retain the 60-second configurable drain and fail the upgrading command if a legacy shared daemon remains busy, protecting the active peer request.

Why: The branch deliberately chooses availability of the already-running peer request over completion of the upgrading caller. That compatibility and user-experience trade-off cannot be resolved safely from automated review evidence alone.

Before merge

  • Resolve merge risk (P2) - The proposed v2 transition intentionally leaves a busy pre-v2 shared daemon running and can fail the new caller after the configured drain timeout rather than terminate an in-flight peer request; maintainers must confirm that upgrade-time failure mode is acceptable.
  • Resolve merge risk (P1) - The review environment prevented independent inspection of the final PR head, current main, git provenance, and matching maintainer notes, so this review cannot certify the final race fix or detect regressions outside the supplied context.
  • Complete next step (P2) - A maintainer must choose the intended upgrade-time behavior for busy pre-v2 daemons; no narrow automated repair is appropriate until that policy is confirmed.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Changed surface 17 files affected; 1,568 additions and 107 deletions The PR changes daemon framing, lifecycle replacement, OAuth tool discovery, and their regression coverage.
Platform checks 3 successful builds The supplied GitHub context reports successful macOS, Ubuntu, and Windows checks on the PR.

Root-cause cluster

Relationship: canonical
Canonical: #241
Summary: This PR is the active replacement for the earlier unmerged OAuth-timeout proposal and adds the missing multi-phase daemon-liveness behavior.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge-risk options

Maintainer options:

  1. Confirm the non-preemptive upgrade policy (recommended)
    Approve the bounded drain-and-fail behavior for busy legacy daemons, then refresh review against the exact head before merge.
  2. Require a compatibility alternative
    Pause the PR until a legacy-daemon transition is chosen that avoids interrupting a peer while allowing the upgrading command to complete.

Technical review

Best possible solution:

Preserve active shared-daemon requests during upgrade, but obtain maintainer agreement that a new client may fail loudly rather than preempt a busy legacy daemon; then re-run a full source and history review against the exact PR head.

Do we have a high-confidence way to reproduce the issue?

No independent high-confidence reproduction was possible in this review because all local read-only commands failed before execution. The contributor supplies a detailed real OAuth-and-pagination before/after run and targeted tests, but those claims could not be checked against the exact head here.

Is this the best way to solve the issue?

Unclear. The supplied branch directly addresses the reported replay and includes a non-preemptive upgrade safeguard, but maintainers must decide whether its bounded failure for a busy legacy daemon is the intended compatibility policy.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 7259c8c2c9b0.

Labels

Label justifications:

  • P1: A timeout/restart in OAuth-backed tool discovery can replay user authorization and disrupt a real CLI workflow.
  • merge-risk: 🚨 availability: The shared daemon’s liveness, timeout, and restart behavior controls whether invoking CLI commands complete or fail.
  • merge-risk: 🚨 compatibility: The versioned protocol transition changes how a newer client handles a live pre-v2 shared daemon.
  • rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦞 diamond lobster and patch quality is 🦐 gold shrimp.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR body supplies a before/after real local OAuth MCP run through the keep-alive daemon, showing one authorization and four list pages without replay after the change; no secrets are exposed in the shown output.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body supplies a before/after real local OAuth MCP run through the keep-alive daemon, showing one authorization and four list pages without replay after the change; no secrets are exposed in the shown output.

Evidence

What I checked:

  • Repository-inspection failure: The required read-only inspection could not start: both the repository wrapper and direct shell command attempts terminated with bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted. No current-tree, git-history, or maintainer-note claim in this review relies on unperformed local inspection.
  • Real behavior proof supplied: The PR body documents an OAuth-protected local MCP server with dynamic registration, PKCE, bearer-gated access, four delayed tools/list pages, and before/after request counts showing one completed listing with no replay on the proposed branch. (tests/daemon-listtools-progress.test.ts:1, fb1901d325fa)
  • Prior review continuity: The provided review context records the earlier busy-daemon replacement finding and later commits that add idle drain, peer-replacement detection, and an unconditional live-PID mismatch short-circuit. The latest supplied head is fb1901d325fa7ae4c84438b1b791e2b8d49b3846, but it could not be inspected locally in this environment. (src/daemon/client.ts, fb1901d325fa)
  • Automated validation supplied: The GitHub context reports successful Ubuntu, macOS, and Windows build checks for the current PR state. These checks supplement, but do not replace, the requested local code and history inspection. (fb1901d325fa)

Likely related people:

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Confirm the busy legacy-daemon transition policy.
  • Re-run source, maintainer-note, and history review once the repository inspection environment is available.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (14 earlier review cycles; latest 8 shown)
  • reviewed 2026-07-29T19:04:25.655Z sha 0e37f7e :: needs maintainer review before merge. :: none
  • reviewed 2026-07-30T03:51:23.678Z sha 0e37f7e :: needs maintainer review before merge. :: none
  • reviewed 2026-07-30T05:41:24.512Z sha 0e37f7e :: needs maintainer review before merge. :: none
  • reviewed 2026-07-30T22:09:19.971Z sha 0e37f7e :: found issues before merge. :: [P1] Avoid stopping a live v1 daemon mid-request
  • reviewed 2026-07-30T23:37:37.102Z sha 0e37f7e :: needs changes before merge. :: [P1] Preserve active v1 daemon requests during replacement
  • reviewed 2026-07-31T12:53:50.413Z sha 0e37f7e :: needs changes before merge. :: [P1] Preserve active v1 daemon requests during replacement
  • reviewed 2026-07-31T14:52:08.903Z sha 4e3977c :: needs maintainer review before merge. :: none
  • reviewed 2026-07-31T15:17:57.867Z sha 74b6953 :: needs maintainer review before merge. :: none

`mcporter auth` runs the interactive OAuth browser flow inside the SDK's
`tools/list` request, but `listTools` never forwarded a per-request timeout. The
SDK's 60s DEFAULT_REQUEST_TIMEOUT_MSEC killed the request long before the 300s
OAuth code wait could finish, so slow providers could never be authorized --
every `mcporter auth` died with `MCP error -32001: Request timed out`.

Mirror the existing `callTool` timeout forwarding:

- add `timeoutMs` to `ListToolsOptions` and pass `{ timeout,
  resetTimeoutOnProgress, maxTotalTimeout }` to `client.listTools`, with
  `raceWithTimeout` as an outer guard;
- have the `auth` path pass `MCPORTER_OAUTH_TIMEOUT_MS` (default 300s) so the
  request lives at least as long as the OAuth code wait;
- carry `timeoutMs` across the daemon protocol so keep-alive servers get the
  same deadline, and report a phase that blows it as `operation_timeout` so the
  keep-alive wrapper stops treating it as a dead server worth restarting;
- version the daemon protocol so a daemon started before this change is
  replaced instead of silently dropping the new field.
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 9a296ab to 6d72274 Compare July 27, 2026 14:47
@umutkeltek

Copy link
Copy Markdown
Contributor Author

Thanks — both review passes found real things. All three items are addressed and the PR body now carries an inspectable before/after run. Summary:

P1 — liveness frames outliving a wedged request. The stated premise doesn't hold: listResources/readResource do have an operation timeout, because every SDK request carries DEFAULT_REQUEST_TIMEOUT_MSEC (60s, shared/protocol.js) and every OAuth wait carries DEFAULT_OAUTH_CODE_TIMEOUT_MS (300s). But chasing it turned up a stronger case than the one reported: McpRuntime.listTools() walks nextCursor with no guard, so a server that repeats a cursor pages forever — and heartbeats would then keep a caller waiting forever. Both ends are now closed:

  • fixed-phase operations (at most one connect plus one MCP request) carry an absolute ceiling equal to the sum of the deadlines the daemon already applies to those phases — derived rather than guessed, so it can only fire once every real deadline has been blown. A server that accepts and never answers now returns a non-retryable operation_timeout instead of hanging.
  • listTools — the one operation whose phase count is data-dependent — keeps its per-page deadline and gains a repeated-cursor guard.

P2 — short deadlines expiring before the first frame. Fixed, and a step further than suggested: an immediate first frame alone still loses a sub-250ms deadline on the second gap, so the cadence is now derived from the caller's own deadline (sent on the request envelope) and the first frame is written immediately on dispatch.

P3 — release-owned changelog. Correct, and I checked it against this repo's history rather than taking it on trust: my own merged #234 didn't touch CHANGELOG.md — the maintainer added the entry at release with the thanks @… credit. Entry removed.

Real behavior proof is in the PR body: a local OAuth-protected MCP server (dynamic registration, PKCE S256, bearer-gated /mcp) paginating tools/list over 4 pages, driven by a real mcporter auth browser flow through the real keep-alive daemon. On 462865b the listing is replayed — page 1 requested twice, 8 tools/list requests, 34s — with the replay starting 17.3s in, exactly the 2 * timeoutMs + 5s budget. On this branch: one authorization, 4 requests, 21s, no restart, no replay.

Each item has regression coverage; pnpm check and pnpm test are green locally (851 passed) and CI is green on Ubuntu, macOS and Windows.

@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jul 27, 2026
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 6d72274 to 2d6d820 Compare July 27, 2026 15:08
@umutkeltek

Copy link
Copy Markdown
Contributor Author

Fixed the remaining P2 — good catch, the finding is exact.

resolveProgressInterval() clamped the cadence up to a 25ms floor, which meant that for any caller deadline at or below 25ms the interval overshot the very deadline it was meant to refresh: the immediate frame landed, then the socket expired before the next one, and invoke() classified that as a dead transport and replayed. The floor was the one constant left in a mechanism whose whole point is not to use constants, so it is gone rather than lowered — the cadence is now min(250, max(1, floor(deadline / 3))), strictly inside the caller's deadline for every deadline above 1ms.

I did not reject short values at the input boundary instead: timeoutMs: 1 is accepted today and pinned by an existing test (clamps daemon status preflight timeout for tiny per-call timeouts), so tightening the boundary would be a separate behavior change. A 1ms deadline stays unachievable at any cadence — as it was before progress frames existed.

Regression added below the old floor, asserting the invariant directly rather than racing a timer: resolveProgressInterval(n) < n across 2, 3, 5, 12, 24, 25, 74, 75, 300, 30_000, 300_000, plus the 1ms boundary and the default-cadence fallbacks.

pnpm check and pnpm test green (853 passed, 3 skipped).

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Jul 27, 2026
Sizing the daemon socket deadline for a fixed number of phases cannot work.
`McpRuntime.listTools()` issues one `tools/list` request per cursor page and
gives each its own `timeoutMs`, so an OAuth wait followed by two or more slow
pages outlives any constant multiple of the caller deadline. The socket then
expires mid-flight, the client restarts the daemon and resends the listing, and
the user is walked through a second OAuth flow.

Stop predicting the phase count and observe the daemon instead. While a request
is in flight the host emits newline-delimited progress frames, and the client
treats each frame as proof of life and restarts its socket deadline. The deadline
becomes a liveness budget rather than an operation budget: a request survives
however many phases it needs, while a daemon that goes silent is still torn down
and restarted exactly as before.

Liveness alone would be too weak, so every operation stays provably bounded:

- the first frame goes out immediately and the cadence is derived from the
  caller's own deadline, so a deadline shorter than the default interval cannot
  expire before the daemon has proved it is alive;
- operations with a fixed phase count -- at most one connect plus one MCP
  request -- carry an absolute ceiling equal to the sum of the deadlines the
  daemon already applies to those phases, so a server that accepts a request and
  never answers yields a non-retryable `operation_timeout` rather than an
  indefinite wait;
- `listTools` keeps its per-page deadline and gains a repeated-cursor guard, so
  the one operation whose phase count is data-dependent cannot page forever.

Both peers decode frames incrementally, so the daemon's own status probe and stop
handshake stay readable, and a response with no trailing newline still parses.

Regression coverage drives the real client transport and the real host framing
over a socket: an OAuth wait followed by three delayed `tools/list` pages
resolves with one `listTools` request, no replay, and no daemon relaunch. Sibling
cases cover a deadline below the default frame interval, a daemon that goes
silent, and a server that repeats a pagination cursor.
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 2d6d820 to 0e37f7e Compare July 27, 2026 15:17
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jul 30, 2026
The previous code's protocol-version check classified any pre-v2 daemon as
stale and called stop() on it from ensureDaemon(). A second client upgrading
mid-request -- OAuth code wait or a delayed cursor page -- would race ahead
of the first client's in-flight call, kill the daemon, and force the very
replay the liveness mechanism was added to prevent.

The fix makes the replacement an idle-drain transition, not a preempt:

* host: the status response now reports the live in-flight count, sampled
  at response time so a long-running request still shows up as in flight.
* protocol: StatusResult.activeRequests is optional so a v1 daemon that
  cannot advertise it does not break old clients.
* client: before restartDaemon() issues stop(), it polls the live daemon
  up to MCPORTER_DAEMON_DRAIN_TIMEOUT_MS (60s default) for activeRequests
  to read zero. Pre-v2 daemons are treated as permanently busy, so the
  drain timeout is the only knob for them too. If the daemon is still
  busy past the timeout, the replacement is abandoned and the caller
  fails loud rather than killing the peer.

Drain timeout is env-var overridable so the test suite can exercise the
refusal branch without waiting the full minute.
Regression coverage for the Codex/ClawSweeper review round:

* daemon-client-config-stale: two new tests pin the drain behavior. The
  first asserts a busy shared daemon is left alone until its in-flight
  count flips to zero, then replaced; the second asserts a daemon that
  never drains is left running rather than killed mid-request.
* daemon-host: three new tests pin the operation ceiling for callTool,
  listResources, and readResource against a wedged runtime, using
  MCPORTER_OAUTH_TIMEOUT_MS=50 and fake timers so the
  OAuth-timeout-plus-60s-DEFAULT_REQUEST_TIMEOUT_MSEC ceiling fires in
  milliseconds rather than the production minute-plus.
* daemon-listtools-progress: one new test exercises a 30ms caller
  deadline -- well under the 250ms default progress cadence -- to prove
  the first progress frame beats the deadline. The existing 120ms test
  already exercised this implicitly; the 30ms case makes the P2 fix
  visible in the test name.
@clawsweeper clawsweeper Bot added status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Jul 31, 2026
The earlier drain landed a contract bug: it only checked whether the
original daemon had gone idle, then immediately fell through to stop().
A second client that won the race to replace the busy daemon during the
drain would see its fresh daemon killed by the upgrading client the
moment the drain returned.

Three changes close the gap:

* client: add `probeLiveStatus` that sends a raw status probe without
  the pid-match filter `readVerifiedStatus` applies. `readVerifiedStatus`
  collapses pid mismatches into null, which would short-circuit the
  drain check and re-introduce the very regression the drain exists to
  prevent.
* client: `waitForDaemonIdle` now returns both `drained` and
  `replacedByPeer`. The pid is tracked across polls so a swap mid-wait
  is visible at the moment the drain resolves.
* client: `restartDaemon` uses `probeLiveStatus` and bails out on
  `replacedByPeer` instead of issuing `stop`.

A third regression test pins the new path: a busy daemon that another
client replaces mid-drain must not receive stop, and the upgrading
client must not relaunch a daemon of its own.
The drain handles the case where a peer swaps in a fresh daemon *while*
the upgrading client is waiting. A subtler race happens *before* the
drain even starts: a peer has already replaced the busy daemon, but the
on-disk metadata still names the old PID, so the previous early-return
check (which also required a fresh config) did not fire and the code
fell through to stop() against the peer's fresh daemon.

Make the pid-mismatch short-circuit in restartDaemon unconditional: if
the live daemon's pid does not match the expected one, do not stop it,
whether or not the metadata still reads stale. The peer owns the
replacement; the upgrading client just uses whatever daemon is live.
This subsumes the pid-mismatch + fresh-config fast path, which is
removed rather than stacked on. The transport-error path is unaffected
because expectedPid is undefined there and the guard is skipped, so the
client still self-heals when no daemon is responding.

A new regression test pins the scenario: a peer wins the swap before
the drain starts, the upgrading client must not call stop, and the
peer's daemon must keep serving.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. P1 Urgent regression or broken agent/channel workflow affecting real users now. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant